package jam4j;

import java.io.File;
import java.io.InputStream;
import java.io.PrintStream;
import java.util.*;
import java.util.regex.Pattern;

import jam4j.build.BuildEngine;
import jam4j.lang.Context;
import jam4j.lang.JamFile;
import jam4j.util.OptionInterpreter;
import jam4j.util.OptionSpec;

/**
 * Main entry point into Jam4J. This class can be invoked from the command
 * line, configured by arguments, or programmatically by another application,
 * configured by method calls.
 * 
 * @author Luke Maurer
 */
public final class Jam4J {
    private String script = JamFile.JAMBASE;
    private final Globals globals = new Globals();
    private boolean defaultLogs = true;
    private final List<String> targets = new ArrayList<String>();
    private final List<String> touched = new ArrayList<String>();
    private final Map<String, String[]> settings =
        new LinkedHashMap<String, String[]>();
    
    /**
     * Specify the script to be run. By default, the internal Jambase is run
     * (it delegates to a file called "Jamfile" by default).
     * 
     * @param filename The filename of the script to run. If a relative filename
     *                is given, will be relative to the directory given by
     *                {@link #workingDirectory(File)}, if any.
     * @return This object.
     */
    public Jam4J script(String filename) {
        this.script = filename;
        return this;
    }
    
    /**
     * Specify the script to be run. By default, the internal Jambase is run
     * (it delegates to a file called "Jamfile" by default).
     * 
     * @param file The script to run.
     * @return This object.
     */
    public Jam4J script(File file) {
        this.script = file.getAbsolutePath();
        return this;
    }
    
    /**
     * Specify an input stream to use rather than standard input.
     * <p>
     * Jam4J never uses {@link System#out}, {@link System#in}, or
     * {@link System#err} directly; instead it uses the corresponding stream
     * from {@link Globals}. This allows input and output to be redirected
     * without disturbing the rest of the JVM. 
     * 
     * @param in The stream to return from {@link Globals#in()}.
     * @return This object.
     * 
     * @see #outputStream(PrintStream)
     * @see #errorStream(PrintStream)
     * @see Globals#in()
     */
    public Jam4J inputStream(InputStream in) {
        globals.in = in;
        return this;
    }
    
    /**
     * Specify an output stream to use rather than standard output.
     * 
     * @param out The stream to return from {@link Globals#out()}.
     * @return This object.
     * 
     * @see #inputStream(InputStream)
     * @see #errorStream(PrintStream)
     * @see Globals#out()
     */
    public Jam4J outputStream(PrintStream out) {
        globals.out = out;
        return this;
    }
    
    /**
     * Specify an output stream to use rather than standard error.
     * 
     * @param err The stream to return from {@link Globals#err()}.
     * @return This object.
     * 
     * @see #inputStream(InputStream)
     * @see #errorStream(PrintStream)
     * @see Globals#err()
     */
    public Jam4J errorStream(PrintStream err) {
        globals.err = err;
        return this;
    }
    
    /**
     * Specify a directory to use as a working directory. Jam4J never uses the
     * actual working directory if this is set.
     * 
     * @param file The directory to return from {@link Globals#cwd()}.
     * @return This object.
     * 
     * @see Globals#cwd()
     */
    public Jam4J workingDirectory(File file) {
        globals.cwd = file;
        return this;
    }
    
    /**
     * Specify a target to build. By default, only "all" is built. This will
     * accumulate with any other calls to this and similar methods.
     * 
     * @param target The name of the target to build.
     * @return This object.
     * 
     * @see #targets(String...)
     * @see #targets(List)
     */
    public Jam4J target(String target) {
        this.targets.add(target);
        return this;
    }
    
    /**
     * Specify a number of targets to build. By default, only "all" is built.
     * This will accumulate with any other calls to this and similar methods.
     * 
     * @param targets The names of the targets to build.
     * @return This object.
     * 
     * @see #target(String)
     * @see #targets(List)
     */
    public Jam4J targets(String ... targets) {
        Collections.addAll(this.targets, targets);
        return this;
    }
    
    /**
     * Specify a number of targets to build. By default, only "all" is built.
     * This will accumulate with any other calls to this and similar methods.
     * 
     * @param targets The names of the targets to build.
     * @return This object.
     * 
     * @see #targets(String...)
     * @see #targets(List)
     */
    public Jam4J targets(List<String> targets) {
        this.targets.addAll(targets);
        return this;
    }
    
    /**
     * Force a target to be built, even if it is not out of date.
     * 
     * @param target The name of the target to touch.
     * @return This object.
     */
    public Jam4J touch(String target) {
        this.touched.add(target);
        return this;
    }
    
    /**
     * Set the name of a variable at startup. This will supersede any
     * environment variable or system property.
     * 
     * @param name The name of the variable to set.
     * @param value The value of the variable, as a string array.
     * @return This object.
     */
    public Jam4J set(String name, String ... value) {
        this.settings.put(name, value);
        return this;
    }
    
    private static String[] unflatten(String name, String value) {
        return value.split(
                PATH_SUFFIX_PATTERN.matcher(name).matches() ?
                    Pattern.quote(File.pathSeparator) : " ");
    }
    
    private static final Pattern PATH_SUFFIX_PATTERN =
        Pattern.compile(".*[Pp][Aa][Tt][Hh]");
    
    /**
     * One of the kinds of output that can be enabled. 
     * 
     * @see Jam4J#display(jam4j.Jam4J.DisplayOption)
     * 
     * @author Luke Maurer
     */
    public enum DisplayOption {
        /** Display all actions, even those with the "quiet" modifier. */
        ALL_ACTIONS,
        /** Indicate what causes each target to be updated. */
        CAUSES,
        /** Output the dependecy tree, in Jam4J syntax. */
        DEPENDENCIES,
        /** Trace all targets and their dependencies, in detail. */
        TRACE_DEPENDENCIES,
        /** Output the text of all commands as they are executed. */
        EXECUTION;
        
        static DisplayOption forLetter(char letter) {
            switch (letter) {
                case 'a': return ALL_ACTIONS;
                case 'c': return CAUSES;
                case 'd': return DEPENDENCIES;
                case 'm': return TRACE_DEPENDENCIES;
                case 'x': return EXECUTION;
                default : return null;
            }
        }
    }
    
    /**
     * Enable a display level, or all levels up to the given one.
     * 
     * @param level A display level, i.e. verbosity level.
     * @param only Whether to enable this level only, or all levels lesser or
     *                equal to this one.
     * @return This object.
     */
    public Jam4J display(int level, boolean only) {
        defaultLogs = false;
        
        if (only)
            globals.enable(level);
        else
            for (int n = 1; n <= level; n++)
                globals.enable(n);
        
        return this;
    }
    
    /**
     * Enable a display option.
     * 
     * @param option The option to enable.
     * @return This object.
     * 
     * @see DisplayOption
     */
    public Jam4J display(DisplayOption option) {
        defaultLogs = false;
        
        switch (option) {
            case ALL_ACTIONS:
                globals.enable(OutputChannel.MAKE);
                globals.enable(OutputChannel.MAKE_QUIET);       break;
            case CAUSES:
                globals.enable(OutputChannel.CAUSES);           break;
            case DEPENDENCIES:
                globals.enable(OutputChannel.DEPENDS);          break;
            case TRACE_DEPENDENCIES:
                globals.enable(OutputChannel.MAKE_PROGRESS);    break;
            case EXECUTION:
                globals.enable(OutputChannel.EXEC);             break;
        }
        
        return this;
    }
    
    /**
     * Disable the default output.
     * 
     * @return This object.
     */
    public Jam4J quiet() {
        defaultLogs = false;
        return this;
    }
    
    /**
     * Set the number of jobs that are allowed to run concurrently. By default,
     * this is 1.
     * 
     * @param jobs The maximum number of update actions that will run at once.
     * @return This object.
     */
    public Jam4J jobs(int jobs) {
        globals.jobs = jobs;
        return this;
    }
    
    /**
     * Update all targets, whether they're out of date or not.
     * 
     * @return This object.
     */
    public Jam4J allTargets() {
        globals.allTargets = true;
        return this;
    }
    
    /**
     * Do not actually execute any commands. Generally most useful when
     * {@link DisplayOption#EXECUTION} is enabled.
     * 
     * @return This object.
     */
    public Jam4J noExec() {
        globals.noExec = true;
        return this;
    }
    
    /**
     * Quit as soon as the first update action fails. By default, the build
     * perseveres, continuing with any targets that did not depend on the
     * failed one.
     * 
     * @return This object.
     */
    public Jam4J quitQuick() {
        globals.quitQuick = true;
        return this;
    }
    
    /**
     * Start the application.
     * 
     * @throws Exception If thrown by the application.
     * @throws Exiting If the "exit" builtin rule is fired.
     */
    public void run() throws Exception {
        if (script == null)
            throw new IllegalStateException("No script given");
        
        if (defaultLogs)
            globals.enable(OutputChannel.MAKE);
        
        final Map<String, String[]> initialVariables =
            new HashMap<String, String[]>();
        
        // Add environment variables, system properties, and passed-in
        // settings to the context
        
        for (Map.Entry<String, String> entry : System.getenv().entrySet()) {
            final String name = entry.getKey(), value = entry.getValue();
            initialVariables.put(name, unflatten(name, value));
        }
        
        for (Map.Entry<?, ?> entry : System.getProperties().entrySet()) {
            final String
                name = (String) entry.getKey(),
                value = (String) entry.getValue();
            initialVariables.put(name, unflatten(name, value));
        }
        
        initialVariables.putAll(settings);
        
        Globals.push(globals);
        try {
            final Context cxt = Context.initialContext(initialVariables);
            
            JamFile.parse(script).run(cxt);
            
            for (String targetName : touched)
                cxt.target(targetName).touch();
            
            if (targets.isEmpty())
                targets.add("all");
            
            BuildEngine.make(cxt, targets);
        } finally {
            Globals.pop();
        }
    }
    
    public static void main(String ... args) throws Exception {
        ((Jam4J) OptionInterpreter.parse(Option.class, args)).run();
    }

    private enum Option implements OptionSpec<Option, Jam4J> {
        @Name("file")
        @ShortName('f')
        @Argument(name="JAMBASE")
        JAMBASE {
            public Jam4J option(String argument, Jam4J config) {
                return config.script(argument);
            }
        },
        @Name({"touch", "force-rebuild"})
        @ShortName('t')
        @Argument(name="TARGET")
        TOUCH {
            public Jam4J option(String argument, Jam4J config) {
                return config.touch(argument);
            }
        },
        @Name("set")
        @ShortName('s')
        @Argument(name="NAME=VALUE")
        SET {
            public Jam4J option(String argument, Jam4J config) {
                final int equalsIx;
                if ((equalsIx = argument.indexOf('=')) == -1)
                    return config;
                
                final String name = argument.substring(0, equalsIx);
                final String value = argument.substring(equalsIx + 1);
                return config.set(name, unflatten(name, value));
            }
        },
        @Name("all")
        @ShortName('a')
        ALL {
            public Jam4J option(String argument, Jam4J config) {
                return config.allTargets();
            }
        },
        @Name({"display", "debug", "log"})
        @ShortName('d')
        @Argument(name="LEVEL", required=true)
        DEBUG {
            public Jam4J option(String argument, Jam4J config)
                    throws IllegalOptionArgumentException {
                try {
                    final boolean only;
                    final int level =
                        Integer.parseInt((only = argument.startsWith("+")) ? 
                                argument.substring(1) : argument);
                    return config.display(level, only);
                } catch (NumberFormatException e) {
                    final int count = argument.length();
                    for (int ix = 0; ix < count; ix++) {
                        final char letter;
                        final DisplayOption option;
                        if ((option = DisplayOption.forLetter(
                                letter = argument.charAt(ix))) == null)
                            throw new IllegalOptionArgumentException(
                                    this, argument,
                                    "Unrecognized display option: " + letter);
                        
                        config.display(option);
                    }
                    
                    return config;
                }
            }
        },
        @Name("jobs")
        @ShortName('j')
        @Argument(name="JOBS", required=true)
        JOBS {
            public Jam4J option(String argument, Jam4J config)
                    throws IllegalOptionArgumentException {
                final int jobs;
                try {
                    jobs = Integer.parseInt(argument);
                } catch (NumberFormatException e) {
                    throw new IllegalOptionArgumentException(this, argument);
                }
                
                return config.jobs(jobs);
            }
        },
        @Name({"dry-run", "no-exec", "fake"})
        @ShortName('n')
        NO_EXEC {
            public Jam4J option(String argument, Jam4J config) {
                return config.noExec();
            }
        },
        @Name("quit-on-error")
        @ShortName('q')
        QUIT_QUICK {
            public Jam4J option(String argument, Jam4J config) {
                return config.quitQuick();
            }
        }
        ;
        
        public abstract Jam4J option(String argument, Jam4J config) 
                throws OptionException;
        
        public final Jam4J argument(String value, Jam4J config)
                throws OptionException {
            return config.target(value);
        }
        
        public final String usage() {
            return "java jam4j.Jam4J [options] [targets]";
        }
        
        public final Jam4J initialState() {
            return new Jam4J();
        }
    }

    public static final String VERSION =
        "Jam4J 0.0.0.1-alpha".substring(0);
    public static final String COPYRIGHT =
        "Copyright 2008 SingleMind Consulting, Inc.".substring(0);
}
